summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt35
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt52
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt6
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt16
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt133
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt214
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt68
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt148
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt418
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt37
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt10
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt83
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt42
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt34
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt15
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt327
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt32
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt19
-rw-r--r--src/android/app/src/main/jni/id_cache.cpp40
-rw-r--r--src/android/app/src/main/jni/id_cache.h6
-rw-r--r--src/android/app/src/main/jni/native.cpp108
-rw-r--r--src/android/app/src/main/jni/native.h2
-rw-r--r--src/android/app/src/main/jni/native_config.cpp26
-rw-r--r--src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml99
-rw-r--r--src/android/app/src/main/res/layout/card_installable.xml3
-rw-r--r--src/android/app/src/main/res/layout/card_simple_outlined.xml (renamed from src/android/app/src/main/res/layout/card_applet_option.xml)20
-rw-r--r--src/android/app/src/main/res/layout/fragment_addons.xml47
-rw-r--r--src/android/app/src/main/res/layout/fragment_game_info.xml125
-rw-r--r--src/android/app/src/main/res/layout/fragment_game_properties.xml86
-rw-r--r--src/android/app/src/main/res/layout/list_item_addon.xml57
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml33
-rw-r--r--src/android/app/src/main/res/values/dimens.xml2
-rw-r--r--src/android/app/src/main/res/values/strings.xml45
40 files changed, 2227 insertions, 253 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index e0f01127c..95b98798d 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -230,8 +230,6 @@ object NativeLibrary {
*/
external fun onTouchReleased(finger_id: Int)
- external fun initGameIni(gameID: String?)
-
external fun setAppDirectory(directory: String)
/**
@@ -241,6 +239,8 @@ object NativeLibrary {
*/
external fun installFileToNand(filename: String, extension: String): Int
+ external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean
+
external fun initializeGpuDriver(
hookLibDir: String?,
customDriverDir: String?,
@@ -252,18 +252,11 @@ object NativeLibrary {
external fun initializeSystem(reload: Boolean)
- external fun defaultCPUCore(): Int
-
/**
* Begins emulation.
*/
external fun run(path: String?)
- /**
- * Begins emulation from the specified savestate.
- */
- external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
-
// Surface Handling
external fun surfaceChanged(surf: Surface?)
@@ -304,10 +297,9 @@ object NativeLibrary {
*/
external fun getCpuBackend(): String
- /**
- * Notifies the core emulation that the orientation has changed.
- */
- external fun notifyOrientationChange(layout_option: Int, rotation: Int)
+ external fun applySettings()
+
+ external fun logSettings()
enum class CoreError {
ErrorSystemFiles,
@@ -539,6 +531,23 @@ object NativeLibrary {
external fun isFirmwareAvailable(): Boolean
/**
+ * Checks the PatchManager for any addons that are available
+ *
+ * @param path Path to game file. Can be a [Uri].
+ * @param programId String representation of a game's program ID
+ * @return Array of pairs where the first value is the name of an addon and the second is the version
+ */
+ external fun getAddonsForFile(path: String, programId: String): Array<Pair<String, String>>?
+
+ /**
+ * Gets the save location for a specific game
+ *
+ * @param programId String representation of a game's program ID
+ * @return Save data path that may not exist yet
+ */
+ external fun getSavePath(programId: String): String
+
+ /**
* Button type for use in onTouchEvent
*/
object ButtonType {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
new file mode 100644
index 000000000..15c7ca3c9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
+import org.yuzu.yuzu_emu.model.Addon
+
+class AddonAdapter : ListAdapter<Addon, AddonAdapter.AddonViewHolder>(
+ AsyncDifferConfig.Builder(DiffCallback()).build()
+) {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
+ ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ .also { return AddonViewHolder(it) }
+ }
+
+ override fun getItemCount(): Int = currentList.size
+
+ override fun onBindViewHolder(holder: AddonViewHolder, position: Int) =
+ holder.bind(currentList[position])
+
+ inner class AddonViewHolder(val binding: ListItemAddonBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(addon: Addon) {
+ binding.root.setOnClickListener {
+ binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked
+ }
+ binding.title.text = addon.title
+ binding.version.text = addon.version
+ binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
+ addon.enabled = checked
+ }
+ binding.addonSwitch.isChecked = addon.enabled
+ }
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback<Addon>() {
+ override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
index a21a705c1..4a05c5be9 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
@@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
-import org.yuzu.yuzu_emu.databinding.CardAppletOptionBinding
+import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
import org.yuzu.yuzu_emu.model.Applet
import org.yuzu.yuzu_emu.model.AppletInfo
import org.yuzu.yuzu_emu.model.Game
@@ -28,7 +28,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
parent: ViewGroup,
viewType: Int
): AppletAdapter.AppletViewHolder {
- CardAppletOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false)
.apply { root.setOnClickListener(this@AppletAdapter) }
.also { return AppletViewHolder(it) }
}
@@ -65,7 +65,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
view.findNavController().navigate(action)
}
- inner class AppletViewHolder(val binding: CardAppletOptionBinding) :
+ inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) :
RecyclerView.ViewHolder(binding.root) {
lateinit var applet: Applet
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
index 2ef638559..928bfe5a7 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
@@ -44,19 +44,20 @@ import org.yuzu.yuzu_emu.utils.GameIconUtils
class GameAdapter(private val activity: AppCompatActivity) :
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
- View.OnClickListener {
+ View.OnClickListener,
+ View.OnLongClickListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
// Create a new view.
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.cardGame.setOnClickListener(this)
+ binding.cardGame.setOnLongClickListener(this)
// Use that view to create a ViewHolder.
return GameViewHolder(binding)
}
- override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+ override fun onBindViewHolder(holder: GameViewHolder, position: Int) =
holder.bind(currentList[position])
- }
override fun getItemCount(): Int = currentList.size
@@ -125,8 +126,15 @@ class GameAdapter(private val activity: AppCompatActivity) :
}
}
- val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game)
+ val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game, true)
+ view.findNavController().navigate(action)
+ }
+
+ override fun onLongClick(view: View): Boolean {
+ val holder = view.tag as GameViewHolder
+ val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(holder.game)
view.findNavController().navigate(action)
+ return true
}
inner class GameViewHolder(val binding: CardGameBinding) :
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
new file mode 100644
index 000000000..ff6270fa8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
@@ -0,0 +1,133 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
+import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
+import org.yuzu.yuzu_emu.model.GameProperty
+import org.yuzu.yuzu_emu.model.InstallableProperty
+import org.yuzu.yuzu_emu.model.SubmenuProperty
+
+class GamePropertiesAdapter(
+ private val viewLifecycle: LifecycleOwner,
+ private var properties: List<GameProperty>
+) :
+ RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): GamePropertyViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ PropertyType.Submenu.ordinal -> {
+ SubmenuPropertyViewHolder(
+ CardSimpleOutlinedBinding.inflate(
+ inflater,
+ parent,
+ false
+ )
+ )
+ }
+
+ else -> InstallablePropertyViewHolder(
+ CardInstallableBinding.inflate(
+ inflater,
+ parent,
+ false
+ )
+ )
+ }
+ }
+
+ override fun getItemCount(): Int = properties.size
+
+ override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) =
+ holder.bind(properties[position])
+
+ override fun getItemViewType(position: Int): Int {
+ return when (properties[position]) {
+ is SubmenuProperty -> PropertyType.Submenu.ordinal
+ else -> PropertyType.Installable.ordinal
+ }
+ }
+
+ sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ abstract fun bind(property: GameProperty)
+ }
+
+ inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) :
+ GamePropertyViewHolder(binding.root) {
+ override fun bind(property: GameProperty) {
+ val submenuProperty = property as SubmenuProperty
+
+ binding.root.setOnClickListener {
+ submenuProperty.action.invoke()
+ }
+
+ binding.title.setText(submenuProperty.titleId)
+ binding.description.setText(submenuProperty.descriptionId)
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ binding.icon.context.resources,
+ submenuProperty.iconId,
+ binding.icon.context.theme
+ )
+ )
+
+ binding.details.postDelayed({
+ binding.details.isSelected = true
+ binding.details.ellipsize = TextUtils.TruncateAt.MARQUEE
+ }, 3000)
+
+ if (submenuProperty.details != null) {
+ binding.details.visibility = View.VISIBLE
+ binding.details.text = submenuProperty.details.invoke()
+ } else if (submenuProperty.detailsFlow != null) {
+ binding.details.visibility = View.VISIBLE
+ viewLifecycle.lifecycleScope.launch {
+ viewLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ submenuProperty.detailsFlow.collect { binding.details.text = it }
+ }
+ }
+ } else {
+ binding.details.visibility = View.GONE
+ }
+ }
+ }
+
+ inner class InstallablePropertyViewHolder(val binding: CardInstallableBinding) :
+ GamePropertyViewHolder(binding.root) {
+ override fun bind(property: GameProperty) {
+ val installableProperty = property as InstallableProperty
+
+ binding.title.setText(installableProperty.titleId)
+ binding.description.setText(installableProperty.descriptionId)
+
+ if (installableProperty.install != null) {
+ binding.buttonInstall.visibility = View.VISIBLE
+ binding.buttonInstall.setOnClickListener { installableProperty.install.invoke() }
+ }
+ if (installableProperty.export != null) {
+ binding.buttonExport.visibility = View.VISIBLE
+ binding.buttonExport.setOnClickListener { installableProperty.export.invoke() }
+ }
+ }
+ }
+
+ enum class PropertyType {
+ Submenu,
+ Installable
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
new file mode 100644
index 000000000..0dce8ad8d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
@@ -0,0 +1,214 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.documentfile.provider.DocumentFile
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.AddonAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding
+import org.yuzu.yuzu_emu.model.AddonViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.AddonUtil
+import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo
+import java.io.File
+
+class AddonsFragment : Fragment() {
+ private var _binding: FragmentAddonsBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+ private val addonViewModel: AddonViewModel by activityViewModels()
+
+ private val args by navArgs<AddonsFragmentArgs>()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ addonViewModel.onOpenAddons(args.game)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAddonsBinding.inflate(inflater)
+ return binding.root
+ }
+
+ // This is using the correct scope, lint is just acting up
+ @SuppressLint("UnsafeRepeatOnLifecycleDetector")
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ homeViewModel.setNavigationVisibility(visible = false, animated = false)
+ homeViewModel.setStatusBarShadeVisibility(false)
+
+ binding.toolbarAddons.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title)
+
+ binding.listAddons.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = AddonAdapter()
+ }
+
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ addonViewModel.addonList.collect {
+ (binding.listAddons.adapter as AddonAdapter).submitList(it)
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ addonViewModel.showModInstallPicker.collect {
+ if (it) {
+ installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
+ addonViewModel.showModInstallPicker(false)
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ addonViewModel.showModNoticeDialog.collect {
+ if (it) {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.addon_notice,
+ descriptionId = R.string.addon_notice_description,
+ positiveAction = { addonViewModel.showModInstallPicker(true) }
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ addonViewModel.showModNoticeDialog(false)
+ }
+ }
+ }
+ }
+ }
+
+ binding.buttonInstall.setOnClickListener {
+ ContentTypeSelectionDialogFragment().show(
+ parentFragmentManager,
+ ContentTypeSelectionDialogFragment.TAG
+ )
+ }
+
+ setInsets()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ addonViewModel.refreshAddons()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ addonViewModel.onCloseAddons()
+ }
+
+ val installAddon =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result)
+ if (externalAddonDirectory == null) {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.invalid_directory,
+ descriptionId = R.string.invalid_directory_description
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ return@registerForActivityResult
+ }
+
+ val isValid = externalAddonDirectory.listFiles()
+ .any { AddonUtil.validAddonDirectories.contains(it.name) }
+ val errorMessage = MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.invalid_directory,
+ descriptionId = R.string.invalid_directory_description
+ )
+ if (isValid) {
+ IndeterminateProgressDialogFragment.newInstance(
+ requireActivity(),
+ R.string.installing_game_content,
+ false
+ ) {
+ val parentDirectoryName = externalAddonDirectory.name
+ val internalAddonDirectory =
+ File(args.game.addonDir + parentDirectoryName)
+ try {
+ externalAddonDirectory.copyFilesTo(internalAddonDirectory)
+ } catch (_: Exception) {
+ return@newInstance errorMessage
+ }
+ addonViewModel.refreshAddons()
+ return@newInstance getString(R.string.addon_installed_successfully)
+ }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ } else {
+ errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpToolbar = binding.toolbarAddons.layoutParams as ViewGroup.MarginLayoutParams
+ mlpToolbar.leftMargin = leftInsets
+ mlpToolbar.rightMargin = rightInsets
+ binding.toolbarAddons.layoutParams = mlpToolbar
+
+ val mlpAddonsList = binding.listAddons.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAddonsList.leftMargin = leftInsets
+ mlpAddonsList.rightMargin = rightInsets
+ binding.listAddons.layoutParams = mlpAddonsList
+ binding.listAddons.updatePadding(
+ bottom = barInsets.bottom +
+ resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
+ )
+
+ val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
+ val mlpFab =
+ binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams
+ mlpFab.leftMargin = leftInsets + fabSpacing
+ mlpFab.rightMargin = rightInsets + fabSpacing
+ mlpFab.bottomMargin = barInsets.bottom + fabSpacing
+ binding.buttonInstall.layoutParams = mlpFab
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
new file mode 100644
index 000000000..c1d8b9ea5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.AddonViewModel
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+
+class ContentTypeSelectionDialogFragment : DialogFragment() {
+ private val addonViewModel: AddonViewModel by activityViewModels()
+
+ private val preferences get() =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ private var selectedItem = 0
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val launchOptions =
+ arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats))
+
+ if (savedInstanceState != null) {
+ selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
+ }
+
+ val mainActivity = requireActivity() as MainActivity
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.select_content_type)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+ when (selectedItem) {
+ 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*"))
+ else -> {
+ if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) {
+ preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply()
+ addonViewModel.showModNoticeDialog(true)
+ return@setPositiveButton
+ }
+ addonViewModel.showModInstallPicker(true)
+ }
+ }
+ }
+ .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
+ selectedItem = i
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putInt(SELECTED_ITEM, selectedItem)
+ }
+
+ companion object {
+ const val TAG = "ContentTypeSelectionDialogFragment"
+
+ private const val SELECTED_ITEM = "SelectedItem"
+ private const val MOD_NOTICE_SEEN = "ModNoticeSeen"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
new file mode 100644
index 000000000..fa2a4c9f9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.navArgs
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.GameMetadata
+
+class GameInfoFragment : Fragment() {
+ private var _binding: FragmentGameInfoBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private val args by navArgs<GameInfoFragmentArgs>()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+
+ // Check for an up-to-date version string
+ args.game.version = GameMetadata.getVersion(args.game.path, true)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGameInfoBinding.inflate(inflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ homeViewModel.setNavigationVisibility(visible = false, animated = false)
+ homeViewModel.setStatusBarShadeVisibility(false)
+
+ binding.apply {
+ toolbarInfo.title = args.game.title
+ toolbarInfo.setNavigationOnClickListener {
+ view.findNavController().popBackStack()
+ }
+
+ val pathString = Uri.parse(args.game.path).path ?: ""
+ path.setHint(R.string.path)
+ pathField.setText(pathString)
+ pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) }
+
+ programId.setHint(R.string.program_id)
+ programIdField.setText(args.game.programIdHex)
+ programIdField.setOnClickListener {
+ copyToClipboard(getString(R.string.program_id), args.game.programIdHex)
+ }
+
+ if (args.game.developer.isNotEmpty()) {
+ developer.setHint(R.string.developer)
+ developerField.setText(args.game.developer)
+ developerField.setOnClickListener {
+ copyToClipboard(getString(R.string.developer), args.game.developer)
+ }
+ } else {
+ developer.visibility = View.GONE
+ }
+
+ version.setHint(R.string.version)
+ versionField.setText(args.game.version)
+ versionField.setOnClickListener {
+ copyToClipboard(getString(R.string.version), args.game.version)
+ }
+
+ buttonCopy.setOnClickListener {
+ val details = """
+ ${args.game.title}
+ ${getString(R.string.path)} - $pathString
+ ${getString(R.string.program_id)} - ${args.game.programIdHex}
+ ${getString(R.string.developer)} - ${args.game.developer}
+ ${getString(R.string.version)} - ${args.game.version}
+ """.trimIndent()
+ copyToClipboard(args.game.title, details)
+ }
+ }
+
+ setInsets()
+ }
+
+ private fun copyToClipboard(label: String, body: String) {
+ val clipBoard =
+ requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, body)
+ clipBoard.setPrimaryClip(clip)
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Toast.makeText(
+ requireContext(),
+ R.string.copied_to_clipboard,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpToolbar = binding.toolbarInfo.layoutParams as ViewGroup.MarginLayoutParams
+ mlpToolbar.leftMargin = leftInsets
+ mlpToolbar.rightMargin = rightInsets
+ binding.toolbarInfo.layoutParams = mlpToolbar
+
+ val mlpScrollAbout = binding.scrollInfo.layoutParams as ViewGroup.MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.scrollInfo.layoutParams = mlpScrollAbout
+
+ binding.contentInfo.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
new file mode 100644
index 000000000..485989e2e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
@@ -0,0 +1,418 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.HomeNavigationDirections
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.model.DriverViewModel
+import org.yuzu.yuzu_emu.model.GameProperty
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.InstallableProperty
+import org.yuzu.yuzu_emu.model.SubmenuProperty
+import org.yuzu.yuzu_emu.model.TaskState
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.GameIconUtils
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import org.yuzu.yuzu_emu.utils.MemoryUtil
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+
+class GamePropertiesFragment : Fragment() {
+ private var _binding: FragmentGamePropertiesBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val driverViewModel: DriverViewModel by activityViewModels()
+
+ private val args by navArgs<GamePropertiesFragmentArgs>()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGamePropertiesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(true)
+
+ binding.buttonBack.setOnClickListener {
+ view.findNavController().popBackStack()
+ }
+
+ GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen)
+ binding.title.text = args.game.title
+ binding.title.postDelayed(
+ {
+ binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.title.isSelected = true
+ },
+ 3000
+ )
+
+ binding.buttonStart.setOnClickListener {
+ LaunchGameDialogFragment.newInstance(args.game)
+ .show(childFragmentManager, LaunchGameDialogFragment.TAG)
+ }
+
+ reloadList()
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ homeViewModel.openImportSaves.collect {
+ if (it) {
+ importSaves.launch(arrayOf("application/zip"))
+ homeViewModel.setOpenImportSaves(false)
+ }
+ }
+ }
+ }
+
+ setInsets()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ gamesViewModel.reloadGames(true)
+ }
+
+ private fun reloadList() {
+ _binding ?: return
+
+ driverViewModel.updateDriverNameForGame(args.game)
+ val properties = mutableListOf<GameProperty>().apply {
+ add(
+ SubmenuProperty(
+ R.string.info,
+ R.string.info_description,
+ R.drawable.ic_info_outline
+ ) {
+ val action = GamePropertiesFragmentDirections
+ .actionPerGamePropertiesFragmentToGameInfoFragment(args.game)
+ binding.root.findNavController().navigate(action)
+ }
+ )
+ add(
+ SubmenuProperty(
+ R.string.preferences_settings,
+ R.string.per_game_settings_description,
+ R.drawable.ic_settings
+ ) {
+ val action = HomeNavigationDirections.actionGlobalSettingsActivity(
+ args.game,
+ Settings.MenuTag.SECTION_ROOT
+ )
+ binding.root.findNavController().navigate(action)
+ }
+ )
+
+ if (!args.game.isHomebrew) {
+ add(
+ SubmenuProperty(
+ R.string.add_ons,
+ R.string.add_ons_description,
+ R.drawable.ic_edit
+ ) {
+ val action = GamePropertiesFragmentDirections
+ .actionPerGamePropertiesFragmentToAddonsFragment(args.game)
+ binding.root.findNavController().navigate(action)
+ }
+ )
+ add(
+ InstallableProperty(
+ R.string.save_data,
+ R.string.save_data_description,
+ {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.import_save_warning,
+ descriptionId = R.string.import_save_warning_description,
+ positiveAction = { homeViewModel.setOpenImportSaves(true) }
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ },
+ if (File(args.game.saveDir).exists()) {
+ { exportSaves.launch(args.game.saveZipName) }
+ } else {
+ null
+ }
+ )
+ )
+
+ val saveDirFile = File(args.game.saveDir)
+ if (saveDirFile.exists()) {
+ add(
+ SubmenuProperty(
+ R.string.delete_save_data,
+ R.string.delete_save_data_description,
+ R.drawable.ic_delete,
+ action = {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.delete_save_data,
+ descriptionId = R.string.delete_save_data_warning_description,
+ positiveAction = {
+ File(args.game.saveDir).deleteRecursively()
+ Toast.makeText(
+ YuzuApplication.appContext,
+ R.string.save_data_deleted_successfully,
+ Toast.LENGTH_SHORT
+ ).show()
+ reloadList()
+ }
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ }
+ )
+ )
+ }
+
+ val shaderCacheDir = File(
+ DirectoryInitialization.userDirectory +
+ "/shader/" + args.game.settingsName.lowercase()
+ )
+ if (shaderCacheDir.exists()) {
+ add(
+ SubmenuProperty(
+ R.string.clear_shader_cache,
+ R.string.clear_shader_cache_description,
+ R.drawable.ic_delete,
+ {
+ if (shaderCacheDir.exists()) {
+ val bytes = shaderCacheDir.walkTopDown().filter { it.isFile }
+ .map { it.length() }.sum()
+ MemoryUtil.bytesToSizeUnit(bytes.toFloat())
+ } else {
+ MemoryUtil.bytesToSizeUnit(0f)
+ }
+ }
+ ) {
+ shaderCacheDir.deleteRecursively()
+ Toast.makeText(
+ YuzuApplication.appContext,
+ R.string.cleared_shaders_successfully,
+ Toast.LENGTH_SHORT
+ ).show()
+ reloadList()
+ }
+ )
+ }
+ }
+ }
+ binding.listProperties.apply {
+ layoutManager =
+ GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns))
+ adapter = GamePropertiesAdapter(viewLifecycleOwner, properties)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ driverViewModel.updateDriverNameForGame(args.game)
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val smallLayout = resources.getBoolean(R.bool.small_layout)
+ if (smallLayout) {
+ val mlpListAll =
+ binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+ mlpListAll.leftMargin = leftInsets
+ mlpListAll.rightMargin = rightInsets
+ binding.listAll.layoutParams = mlpListAll
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.root) ==
+ ViewCompat.LAYOUT_DIRECTION_LTR
+ ) {
+ val mlpListAll =
+ binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+ mlpListAll.rightMargin = rightInsets
+ binding.listAll.layoutParams = mlpListAll
+
+ val mlpIconLayout =
+ binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
+ mlpIconLayout.topMargin = barInsets.top
+ mlpIconLayout.leftMargin = leftInsets
+ binding.iconLayout!!.layoutParams = mlpIconLayout
+ } else {
+ val mlpListAll =
+ binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+ mlpListAll.leftMargin = leftInsets
+ binding.listAll.layoutParams = mlpListAll
+
+ val mlpIconLayout =
+ binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
+ mlpIconLayout.topMargin = barInsets.top
+ mlpIconLayout.rightMargin = rightInsets
+ binding.iconLayout!!.layoutParams = mlpIconLayout
+ }
+ }
+
+ val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
+ val mlpFab =
+ binding.buttonStart.layoutParams as ViewGroup.MarginLayoutParams
+ mlpFab.leftMargin = leftInsets + fabSpacing
+ mlpFab.rightMargin = rightInsets + fabSpacing
+ mlpFab.bottomMargin = barInsets.bottom + fabSpacing
+ binding.buttonStart.layoutParams = mlpFab
+
+ binding.layoutAll.updatePadding(
+ top = barInsets.top,
+ bottom = barInsets.bottom +
+ resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
+ )
+
+ windowInsets
+ }
+
+ private val importSaves =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ val inputZip = requireContext().contentResolver.openInputStream(result)
+ val savesFolder = File(args.game.saveDir)
+ val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
+ cacheSaveDir.mkdir()
+
+ if (inputZip == null) {
+ Toast.makeText(
+ YuzuApplication.appContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ return@registerForActivityResult
+ }
+
+ IndeterminateProgressDialogFragment.newInstance(
+ requireActivity(),
+ R.string.save_files_importing,
+ false
+ ) {
+ try {
+ FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+ val files = cacheSaveDir.listFiles()
+ var savesFolderFile: File? = null
+ if (files != null) {
+ val savesFolderName = args.game.programIdHex
+ for (file in files) {
+ if (file.isDirectory && file.name == savesFolderName) {
+ savesFolderFile = file
+ break
+ }
+ }
+ }
+
+ if (savesFolderFile != null) {
+ savesFolder.deleteRecursively()
+ savesFolder.mkdir()
+ savesFolderFile.copyRecursively(savesFolder)
+ savesFolderFile.deleteRecursively()
+ }
+
+ withContext(Dispatchers.Main) {
+ if (savesFolderFile == null) {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.save_file_invalid_zip_structure,
+ descriptionId = R.string.save_file_invalid_zip_structure_description
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ return@withContext
+ }
+ Toast.makeText(
+ YuzuApplication.appContext,
+ getString(R.string.save_file_imported_success),
+ Toast.LENGTH_LONG
+ ).show()
+ reloadList()
+ }
+
+ cacheSaveDir.deleteRecursively()
+ } catch (e: Exception) {
+ Toast.makeText(
+ YuzuApplication.appContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
+
+ /**
+ * Exports the save file located in the given folder path by creating a zip file and opening a
+ * file picker to save.
+ */
+ private val exportSaves = registerForActivityResult(
+ ActivityResultContracts.CreateDocument("application/zip")
+ ) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ IndeterminateProgressDialogFragment.newInstance(
+ requireActivity(),
+ R.string.save_files_exporting,
+ false
+ ) {
+ val saveLocation = args.game.saveDir
+ val zipResult = FileUtil.zipFromInternalStorage(
+ File(saveLocation),
+ saveLocation.replaceAfterLast("/", ""),
+ BufferedOutputStream(requireContext().contentResolver.openOutputStream(result))
+ )
+ return@newInstance when (zipResult) {
+ TaskState.Completed -> getString(R.string.export_success)
+ TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
+ }
+ }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
index 7e467814d..8847e5531 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -122,7 +122,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
activity: FragmentActivity,
titleId: Int,
cancellable: Boolean = false,
- task: () -> Any
+ task: suspend () -> Any
): IndeterminateProgressDialogFragment {
val dialog = IndeterminateProgressDialogFragment()
val args = Bundle()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
new file mode 100644
index 000000000..f653826a6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.HomeNavigationDirections
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+
+class LaunchGameDialogFragment : DialogFragment() {
+ private var selectedItem = 0
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val game = requireArguments().parcelable<Game>(GAME)
+ val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom))
+
+ if (savedInstanceState != null) {
+ selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
+ }
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.launch_options)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+ val action = HomeNavigationDirections
+ .actionGlobalEmulationActivity(game, selectedItem != 0)
+ requireParentFragment().findNavController().navigate(action)
+ }
+ .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
+ selectedItem = i
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putInt(SELECTED_ITEM, selectedItem)
+ }
+
+ companion object {
+ const val TAG = "LaunchGameDialogFragment"
+
+ const val GAME = "Game"
+ const val SELECTED_ITEM = "SelectedItem"
+
+ fun newInstance(game: Game): LaunchGameDialogFragment {
+ val args = Bundle()
+ args.putParcelable(GAME, game)
+ val fragment = LaunchGameDialogFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
index a6183d19e..32062b6fe 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
@@ -27,30 +27,31 @@ class MessageDialogFragment : DialogFragment() {
val descriptionString = requireArguments().getString(DESCRIPTION_STRING)!!
val helpLinkId = requireArguments().getInt(HELP_LINK)
- val dialog = MaterialAlertDialogBuilder(requireContext())
- .setPositiveButton(R.string.close, null)
+ val builder = MaterialAlertDialogBuilder(requireContext())
- if (titleId != 0) dialog.setTitle(titleId)
- if (titleString.isNotEmpty()) dialog.setTitle(titleString)
+ if (messageDialogViewModel.positiveAction == null) {
+ builder.setPositiveButton(R.string.close, null)
+ } else {
+ builder.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+ messageDialogViewModel.positiveAction?.invoke()
+ }.setNegativeButton(android.R.string.cancel, null)
+ }
+
+ if (titleId != 0) builder.setTitle(titleId)
+ if (titleString.isNotEmpty()) builder.setTitle(titleString)
if (descriptionId != 0) {
- dialog.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY))
+ builder.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY))
}
- if (descriptionString.isNotEmpty()) dialog.setMessage(descriptionString)
+ if (descriptionString.isNotEmpty()) builder.setMessage(descriptionString)
if (helpLinkId != 0) {
- dialog.setNeutralButton(R.string.learn_more) { _, _ ->
+ builder.setNeutralButton(R.string.learn_more) { _, _ ->
openLink(getString(helpLinkId))
}
}
- return dialog.show()
- }
-
- override fun onDismiss(dialog: DialogInterface) {
- super.onDismiss(dialog)
- messageDialogViewModel.dismissAction.invoke()
- messageDialogViewModel.clear()
+ return builder.show()
}
private fun openLink(link: String) {
@@ -74,7 +75,7 @@ class MessageDialogFragment : DialogFragment() {
descriptionId: Int = 0,
descriptionString: String = "",
helpLinkId: Int = 0,
- dismissAction: () -> Unit = {}
+ positiveAction: (() -> Unit)? = null
): MessageDialogFragment {
val dialog = MessageDialogFragment()
val bundle = Bundle()
@@ -85,8 +86,10 @@ class MessageDialogFragment : DialogFragment() {
putString(DESCRIPTION_STRING, descriptionString)
putInt(HELP_LINK, helpLinkId)
}
- ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction =
- dismissAction
+ ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply {
+ clear()
+ this.positiveAction = positiveAction
+ }
dialog.arguments = bundle
return dialog
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
index 2dbca76a5..3ac054d8f 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
@@ -60,7 +60,9 @@ class SearchFragment : Fragment() {
// This is using the correct scope, lint is just acting up
@SuppressLint("UnsafeRepeatOnLifecycleDetector")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- homeViewModel.setNavigationVisibility(visible = true, animated = false)
+ super.onViewCreated(view, savedInstanceState)
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(true)
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
if (savedInstanceState != null) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
new file mode 100644
index 000000000..ed79a8b02
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class Addon(
+ var enabled: Boolean,
+ val title: String,
+ val version: String
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
new file mode 100644
index 000000000..075252f5b
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.NativeConfig
+import java.util.concurrent.atomic.AtomicBoolean
+
+class AddonViewModel : ViewModel() {
+ private val _addonList = MutableStateFlow(mutableListOf<Addon>())
+ val addonList get() = _addonList.asStateFlow()
+
+ private val _showModInstallPicker = MutableStateFlow(false)
+ val showModInstallPicker get() = _showModInstallPicker.asStateFlow()
+
+ private val _showModNoticeDialog = MutableStateFlow(false)
+ val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow()
+
+ var game: Game? = null
+
+ private val isRefreshing = AtomicBoolean(false)
+
+ fun onOpenAddons(game: Game) {
+ this.game = game
+ refreshAddons()
+ }
+
+ fun refreshAddons() {
+ if (isRefreshing.get() || game == null) {
+ return
+ }
+ isRefreshing.set(true)
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ val addonList = mutableListOf<Addon>()
+ val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId)
+ NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach {
+ val name = it.first.replace("[D] ", "")
+ addonList.add(Addon(!disabledAddons.contains(name), name, it.second))
+ }
+ addonList.sortBy { it.title }
+ _addonList.value = addonList
+ isRefreshing.set(false)
+ }
+ }
+ }
+
+ fun onCloseAddons() {
+ if (_addonList.value.isEmpty()) {
+ return
+ }
+
+ NativeConfig.setDisabledAddons(
+ game!!.programId,
+ _addonList.value.mapNotNull {
+ if (it.enabled) {
+ null
+ } else {
+ it.title
+ }
+ }.toTypedArray()
+ )
+ NativeConfig.saveGlobalConfig()
+ _addonList.value.clear()
+ game = null
+ }
+
+ fun showModInstallPicker(install: Boolean) {
+ _showModInstallPicker.value = install
+ }
+
+ fun showModNoticeDialog(show: Boolean) {
+ _showModNoticeDialog.value = show
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
index 2fa3ab31b..ac642c16e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -3,10 +3,18 @@
package org.yuzu.yuzu_emu.model
+import android.net.Uri
import android.os.Parcelable
import java.util.HashSet
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
@Parcelize
@Serializable
@@ -15,12 +23,44 @@ class Game(
val path: String,
val programId: String = "",
val developer: String = "",
- val version: String = "",
+ var version: String = "",
val isHomebrew: Boolean = false
) : Parcelable {
val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime"
val keyLastPlayedTime get() = "${path}_LastPlayed"
+ val settingsName: String
+ get() {
+ val programIdLong = programId.toLong()
+ return if (programIdLong == 0L) {
+ FileUtil.getFilename(Uri.parse(path))
+ } else {
+ "0" + programIdLong.toString(16).uppercase()
+ }
+ }
+
+ val programIdHex: String
+ get() {
+ val programIdLong = programId.toLong()
+ return if (programIdLong == 0L) {
+ "0"
+ } else {
+ "0" + programIdLong.toString(16).uppercase()
+ }
+ }
+
+ val saveZipName: String
+ get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ }.zip"
+
+ val saveDir: String
+ get() = DirectoryInitialization.userDirectory + "/nand" +
+ NativeLibrary.getSavePath(programId)
+
+ val addonDir: String
+ get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"
+
override fun equals(other: Any?): Boolean {
if (other !is Game) {
return false
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
new file mode 100644
index 000000000..bb3df5bd0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import kotlinx.coroutines.flow.StateFlow
+
+interface GameProperty {
+ @get:StringRes
+ val titleId: Int
+ get() = -1
+
+ @get:StringRes
+ val descriptionId: Int
+ get() = -1
+}
+
+data class SubmenuProperty(
+ override val titleId: Int,
+ override val descriptionId: Int,
+ @DrawableRes val iconId: Int,
+ val details: (() -> String)? = null,
+ val detailsFlow: StateFlow<String>? = null,
+ val action: () -> Unit
+) : GameProperty
+
+data class InstallableProperty(
+ override val titleId: Int,
+ override val descriptionId: Int,
+ val install: (() -> Unit)? = null,
+ val export: (() -> Unit)? = null
+) : GameProperty
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
index 07e65b028..d801db105 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
@@ -3,6 +3,7 @@
package org.yuzu.yuzu_emu.model
+import android.net.Uri
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +22,12 @@ class HomeViewModel : ViewModel() {
private val _gamesDirSelected = MutableStateFlow(false)
val gamesDirSelected get() = _gamesDirSelected.asStateFlow()
+ private val _openImportSaves = MutableStateFlow(false)
+ val openImportSaves get() = _openImportSaves.asStateFlow()
+
+ private val _contentToInstall = MutableStateFlow<List<Uri>?>(null)
+ val contentToInstall get() = _contentToInstall.asStateFlow()
+
var navigatedToSetup = false
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@@ -44,4 +51,12 @@ class HomeViewModel : ViewModel() {
fun setGamesDirSelected(selected: Boolean) {
_gamesDirSelected.value = selected
}
+
+ fun setOpenImportSaves(import: Boolean) {
+ _openImportSaves.value = import
+ }
+
+ fun setContentToInstall(documents: List<Uri>?) {
+ _contentToInstall.value = documents
+ }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
index 36ffd08d2..641c5cb17 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
@@ -6,9 +6,9 @@ package org.yuzu.yuzu_emu.model
import androidx.lifecycle.ViewModel
class MessageDialogViewModel : ViewModel() {
- var dismissAction: () -> Unit = {}
+ var positiveAction: (() -> Unit)? = null
fun clear() {
- dismissAction = {}
+ positiveAction = null
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
index 16a794dee..e59c95733 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -23,7 +23,7 @@ class TaskViewModel : ViewModel() {
val cancelled: StateFlow<Boolean> get() = _cancelled
private val _cancelled = MutableStateFlow(false)
- lateinit var task: () -> Any
+ lateinit var task: suspend () -> Any
fun clear() {
_result.value = Any()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
index 805b89b31..d5acf8479 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
@@ -19,7 +19,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.color.MaterialColors
-import com.google.android.material.transition.MaterialFadeThrough
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.GameAdapter
@@ -35,11 +35,6 @@ class GamesFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enterTransition = MaterialFadeThrough()
- }
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -52,7 +47,9 @@ class GamesFragment : Fragment() {
// This is using the correct scope, lint is just acting up
@SuppressLint("UnsafeRepeatOnLifecycleDetector")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- homeViewModel.setNavigationVisibility(visible = true, animated = false)
+ super.onViewCreated(view, savedInstanceState)
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(true)
binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager(
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 16323a316..09ddd1bbd 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -43,7 +43,7 @@ import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
-import org.yuzu.yuzu_emu.getPublicFilesDir
+import org.yuzu.yuzu_emu.model.AddonViewModel
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.model.TaskState
@@ -60,15 +60,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
private val homeViewModel: HomeViewModel by viewModels()
private val gamesViewModel: GamesViewModel by viewModels()
private val taskViewModel: TaskViewModel by viewModels()
+ private val addonViewModel: AddonViewModel by viewModels()
override var themeId: Int = 0
- private val savesFolder
- get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
-
- // Get first subfolder in saves folder (should be the user folder)
- val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
-
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -145,6 +140,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
homeViewModel.statusBarShadeVisible.collect { showStatusBarShade(it) }
}
}
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.contentToInstall.collect {
+ if (it != null) {
+ installContent(it)
+ homeViewModel.setContentToInstall(null)
+ }
+ }
+ }
+ }
}
// Dismiss previous notifications (should not happen unless a crash occurred)
@@ -468,110 +473,150 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val installGameUpdate = registerForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { documents: List<Uri> ->
- if (documents.isNotEmpty()) {
- IndeterminateProgressDialogFragment.newInstance(
- this@MainActivity,
- R.string.installing_game_content
- ) {
- var installSuccess = 0
- var installOverwrite = 0
- var errorBaseGame = 0
- var errorExtension = 0
- var errorOther = 0
- documents.forEach {
- when (
- NativeLibrary.installFileToNand(
- it.toString(),
- FileUtil.getExtension(it)
- )
- ) {
- NativeLibrary.InstallFileToNandResult.Success -> {
- installSuccess += 1
- }
+ if (documents.isEmpty()) {
+ return@registerForActivityResult
+ }
- NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
- installOverwrite += 1
- }
+ if (addonViewModel.game == null) {
+ installContent(documents)
+ return@registerForActivityResult
+ }
- NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
- errorBaseGame += 1
- }
+ IndeterminateProgressDialogFragment.newInstance(
+ this@MainActivity,
+ R.string.verifying_content,
+ false
+ ) {
+ var updatesMatchProgram = true
+ for (document in documents) {
+ val valid = NativeLibrary.doesUpdateMatchProgram(
+ addonViewModel.game!!.programId,
+ document.toString()
+ )
+ if (!valid) {
+ updatesMatchProgram = false
+ break
+ }
+ }
- NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
- errorExtension += 1
- }
+ if (updatesMatchProgram) {
+ homeViewModel.setContentToInstall(documents)
+ } else {
+ MessageDialogFragment.newInstance(
+ this@MainActivity,
+ titleId = R.string.content_install_notice,
+ descriptionId = R.string.content_install_notice_description,
+ positiveAction = { homeViewModel.setContentToInstall(documents) }
+ )
+ }
+ }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
- else -> {
- errorOther += 1
- }
+ private fun installContent(documents: List<Uri>) {
+ IndeterminateProgressDialogFragment.newInstance(
+ this@MainActivity,
+ R.string.installing_game_content
+ ) {
+ var installSuccess = 0
+ var installOverwrite = 0
+ var errorBaseGame = 0
+ var errorExtension = 0
+ var errorOther = 0
+ documents.forEach {
+ when (
+ NativeLibrary.installFileToNand(
+ it.toString(),
+ FileUtil.getExtension(it)
+ )
+ ) {
+ NativeLibrary.InstallFileToNandResult.Success -> {
+ installSuccess += 1
+ }
+
+ NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
+ installOverwrite += 1
+ }
+
+ NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
+ errorBaseGame += 1
+ }
+
+ NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
+ errorExtension += 1
+ }
+
+ else -> {
+ errorOther += 1
}
}
+ }
- val separator = System.getProperty("line.separator") ?: "\n"
- val installResult = StringBuilder()
- if (installSuccess > 0) {
- installResult.append(
- getString(
- R.string.install_game_content_success_install,
- installSuccess
- )
+ addonViewModel.refreshAddons()
+
+ val separator = System.getProperty("line.separator") ?: "\n"
+ val installResult = StringBuilder()
+ if (installSuccess > 0) {
+ installResult.append(
+ getString(
+ R.string.install_game_content_success_install,
+ installSuccess
+ )
+ )
+ installResult.append(separator)
+ }
+ if (installOverwrite > 0) {
+ installResult.append(
+ getString(
+ R.string.install_game_content_success_overwrite,
+ installOverwrite
)
+ )
+ installResult.append(separator)
+ }
+ val errorTotal: Int = errorBaseGame + errorExtension + errorOther
+ if (errorTotal > 0) {
+ installResult.append(separator)
+ installResult.append(
+ getString(
+ R.string.install_game_content_failed_count,
+ errorTotal
+ )
+ )
+ installResult.append(separator)
+ if (errorBaseGame > 0) {
installResult.append(separator)
- }
- if (installOverwrite > 0) {
installResult.append(
- getString(
- R.string.install_game_content_success_overwrite,
- installOverwrite
- )
+ getString(R.string.install_game_content_failure_base)
)
installResult.append(separator)
}
- val errorTotal: Int = errorBaseGame + errorExtension + errorOther
- if (errorTotal > 0) {
+ if (errorExtension > 0) {
installResult.append(separator)
installResult.append(
- getString(
- R.string.install_game_content_failed_count,
- errorTotal
- )
+ getString(R.string.install_game_content_failure_file_extension)
)
installResult.append(separator)
- if (errorBaseGame > 0) {
- installResult.append(separator)
- installResult.append(
- getString(R.string.install_game_content_failure_base)
- )
- installResult.append(separator)
- }
- if (errorExtension > 0) {
- installResult.append(separator)
- installResult.append(
- getString(R.string.install_game_content_failure_file_extension)
- )
- installResult.append(separator)
- }
- if (errorOther > 0) {
- installResult.append(
- getString(R.string.install_game_content_failure_description)
- )
- installResult.append(separator)
- }
- return@newInstance MessageDialogFragment.newInstance(
- this,
- titleId = R.string.install_game_content_failure,
- descriptionString = installResult.toString().trim(),
- helpLinkId = R.string.install_game_content_help_link
- )
- } else {
- return@newInstance MessageDialogFragment.newInstance(
- this,
- titleId = R.string.install_game_content_success,
- descriptionString = installResult.toString().trim()
+ }
+ if (errorOther > 0) {
+ installResult.append(
+ getString(R.string.install_game_content_failure_description)
)
+ installResult.append(separator)
}
- }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
- }
+ return@newInstance MessageDialogFragment.newInstance(
+ this,
+ titleId = R.string.install_game_content_failure,
+ descriptionString = installResult.toString().trim(),
+ helpLinkId = R.string.install_game_content_help_link
+ )
+ } else {
+ return@newInstance MessageDialogFragment.newInstance(
+ this,
+ titleId = R.string.install_game_content_success,
+ descriptionString = installResult.toString().trim()
+ )
+ }
+ }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
}
val exportUserData = registerForActivityResult(
@@ -657,102 +702,4 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
return@newInstance getString(R.string.user_data_import_success)
}.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
}
-
- /**
- * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
- */
- val exportSaves = registerForActivityResult(
- ActivityResultContracts.CreateDocument("application/zip")
- ) { result ->
- if (result == null) {
- return@registerForActivityResult
- }
-
- IndeterminateProgressDialogFragment.newInstance(
- this,
- R.string.save_files_exporting,
- false
- ) {
- val zipResult = FileUtil.zipFromInternalStorage(
- File(savesFolderRoot),
- savesFolderRoot,
- BufferedOutputStream(contentResolver.openOutputStream(result))
- )
- return@newInstance when (zipResult) {
- TaskState.Completed -> getString(R.string.export_success)
- TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
- }
- }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
- }
-
- private val startForResultExportSave =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
- File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
- }
-
- val importSaves =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
- if (result == null) {
- return@registerForActivityResult
- }
-
- NativeLibrary.initializeEmptyUserDirectory()
-
- val inputZip = contentResolver.openInputStream(result)
- // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
- var validZip = false
- val savesFolder = File(savesFolderRoot)
- val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/")
- cacheSaveDir.mkdir()
-
- if (inputZip == null) {
- Toast.makeText(
- applicationContext,
- getString(R.string.fatal_error),
- Toast.LENGTH_LONG
- ).show()
- return@registerForActivityResult
- }
-
- val filterTitleId =
- FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
-
- try {
- CoroutineScope(Dispatchers.IO).launch {
- FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
- cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
- File(savesFolder, savePath).deleteRecursively()
- File(cacheSaveDir, savePath).copyRecursively(
- File(savesFolder, savePath),
- true
- )
- validZip = true
- }
-
- withContext(Dispatchers.Main) {
- if (!validZip) {
- MessageDialogFragment.newInstance(
- this@MainActivity,
- titleId = R.string.save_file_invalid_zip_structure,
- descriptionId = R.string.save_file_invalid_zip_structure_description
- ).show(supportFragmentManager, MessageDialogFragment.TAG)
- return@withContext
- }
- Toast.makeText(
- applicationContext,
- getString(R.string.save_file_imported_success),
- Toast.LENGTH_LONG
- ).show()
- }
-
- cacheSaveDir.deleteRecursively()
- }
- } catch (e: Exception) {
- Toast.makeText(
- applicationContext,
- getString(R.string.fatal_error),
- Toast.LENGTH_LONG
- ).show()
- }
- }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
new file mode 100644
index 000000000..8cc5ea71f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+object AddonUtil {
+ val validAddonDirectories = listOf("cheats", "exefs", "romfs")
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
index bbe7bfa92..00c6bf90e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -22,6 +22,7 @@ import java.io.BufferedOutputStream
import java.lang.NullPointerException
import java.nio.charset.StandardCharsets
import java.util.zip.ZipOutputStream
+import kotlin.IllegalStateException
object FileUtil {
const val PATH_TREE = "tree"
@@ -342,6 +343,37 @@ object FileUtil {
return TaskState.Completed
}
+ /**
+ * Helper function that copies the contents of a DocumentFile folder into a [File]
+ * @param file [File] representation of the folder to copy into
+ * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa
+ */
+ fun DocumentFile.copyFilesTo(file: File) {
+ file.mkdirs()
+ if (!this.isDirectory || !file.isDirectory) {
+ throw IllegalStateException(
+ "[FileUtil] Tried to copy a folder into a file or vice versa"
+ )
+ }
+
+ this.listFiles().forEach {
+ val newFile = File(file, it.name!!)
+ if (it.isDirectory) {
+ newFile.mkdirs()
+ DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile)
+ } else {
+ val inputStream =
+ YuzuApplication.appContext.contentResolver.openInputStream(it.uri)
+ BufferedInputStream(inputStream).use { bos ->
+ if (!newFile.exists()) {
+ newFile.createNewFile()
+ }
+ newFile.outputStream().use { os -> bos.copyTo(os) }
+ }
+ }
+ }
+ }
+
fun isRootTreeUri(uri: Uri): Boolean {
val paths = uri.pathSegments
return paths.size == 2 && PATH_TREE == paths[0]
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
index 4c7316ba3..7d629b7d5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
@@ -105,4 +105,23 @@ object NativeConfig {
*/
@Synchronized
external fun addGameDir(dir: GameDir)
+
+ /**
+ * Gets an array of the addons that are disabled for a given game
+ *
+ * @param programId String representation of a game's program ID
+ * @return An array of disabled addons
+ */
+ @Synchronized
+ external fun getDisabledAddons(programId: String): Array<String>
+
+ /**
+ * Clears the disabled addons array corresponding to [programId] and replaces them
+ * with [disabledAddons]
+ *
+ * @param programId String representation of a game's program ID
+ * @param disabledAddons Replacement array of disabled addons
+ */
+ @Synchronized
+ external fun setDisabledAddons(programId: String, disabledAddons: Array<String>)
}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index a56ed5662..df8935178 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -20,6 +20,12 @@ static jmethodID s_disk_cache_load_progress;
static jmethodID s_on_emulation_started;
static jmethodID s_on_emulation_stopped;
+static jclass s_string_class;
+static jclass s_pair_class;
+static jmethodID s_pair_constructor;
+static jfieldID s_pair_first_field;
+static jfieldID s_pair_second_field;
+
static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
namespace IDCache {
@@ -79,6 +85,26 @@ jmethodID GetOnEmulationStopped() {
return s_on_emulation_stopped;
}
+jclass GetStringClass() {
+ return s_string_class;
+}
+
+jclass GetPairClass() {
+ return s_pair_class;
+}
+
+jmethodID GetPairConstructor() {
+ return s_pair_constructor;
+}
+
+jfieldID GetPairFirstField() {
+ return s_pair_first_field;
+}
+
+jfieldID GetPairSecondField() {
+ return s_pair_second_field;
+}
+
} // namespace IDCache
#ifdef __cplusplus
@@ -115,6 +141,18 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_on_emulation_stopped =
env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V");
+ const jclass string_class = env->FindClass("java/lang/String");
+ s_string_class = reinterpret_cast<jclass>(env->NewGlobalRef(string_class));
+ env->DeleteLocalRef(string_class);
+
+ const jclass pair_class = env->FindClass("kotlin/Pair");
+ s_pair_class = reinterpret_cast<jclass>(env->NewGlobalRef(pair_class));
+ s_pair_constructor =
+ env->GetMethodID(pair_class, "<init>", "(Ljava/lang/Object;Ljava/lang/Object;)V");
+ s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;");
+ s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;");
+ env->DeleteLocalRef(pair_class);
+
// Initialize Android Storage
Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
@@ -136,6 +174,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
env->DeleteGlobalRef(s_disk_cache_progress_class);
env->DeleteGlobalRef(s_load_callback_stage_class);
env->DeleteGlobalRef(s_game_dir_class);
+ env->DeleteGlobalRef(s_string_class);
+ env->DeleteGlobalRef(s_pair_class);
// UnInitialize applets
SoftwareKeyboard::CleanupJNI(env);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index 855649efa..36233423e 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -20,4 +20,10 @@ jmethodID GetDiskCacheLoadProgress();
jmethodID GetOnEmulationStarted();
jmethodID GetOnEmulationStopped();
+jclass GetStringClass();
+jclass GetPairClass();
+jmethodID GetPairConstructor();
+jfieldID GetPairFirstField();
+jfieldID GetPairSecondField();
+
} // namespace IDCache
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index e5d3158c8..ce570b811 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -14,6 +14,7 @@
#include <android/api-level.h>
#include <android/native_window_jni.h>
#include <common/fs/fs.h>
+#include <core/file_sys/patch_manager.h>
#include <core/file_sys/savedata_factory.h>
#include <core/loader/nro.h>
#include <jni.h>
@@ -79,6 +80,10 @@ Core::System& EmulationSession::System() {
return m_system;
}
+FileSys::ManualContentProvider* EmulationSession::ContentProvider() {
+ return m_manual_provider.get();
+}
+
const EmuWindow_Android& EmulationSession::Window() const {
return *m_window;
}
@@ -455,6 +460,15 @@ void EmulationSession::OnEmulationStopped(Core::SystemResultStatus result) {
static_cast<jint>(result));
}
+u64 EmulationSession::GetProgramId(JNIEnv* env, jstring jprogramId) {
+ auto program_id_string = GetJString(env, jprogramId);
+ try {
+ return std::stoull(program_id_string);
+ } catch (...) {
+ return 0;
+ }
+}
+
static Core::SystemResultStatus RunEmulation(const std::string& filepath) {
MicroProfileOnThreadCreate("EmuThread");
SCOPE_EXIT({ MicroProfileShutdown(); });
@@ -504,6 +518,27 @@ int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject
GetJString(env, j_file_extension));
}
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj,
+ jstring jprogramId,
+ jstring jupdatePath) {
+ u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
+ std::string updatePath = GetJString(env, jupdatePath);
+ std::shared_ptr<FileSys::NSP> nsp = std::make_shared<FileSys::NSP>(
+ EmulationSession::GetInstance().System().GetFilesystem()->OpenFile(updatePath,
+ FileSys::Mode::Read));
+ for (const auto& item : nsp->GetNCAs()) {
+ for (const auto& nca_details : item.second) {
+ if (nca_details.second->GetName().ends_with(".cnmt.nca")) {
+ auto update_id = nca_details.second->GetTitleId() & ~0xFFFULL;
+ if (update_id == program_id) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz,
jstring hook_lib_dir,
jstring custom_driver_dir,
@@ -665,13 +700,6 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass
EmulationSession::GetInstance().InitializeSystem(reload);
}
-jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore(JNIEnv* env, jclass clazz) {
- return {};
-}
-
-void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z(
- JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate) {}
-
jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats(JNIEnv* env, jclass clazz) {
jdoubleArray j_stats = env->NewDoubleArray(4);
@@ -696,9 +724,13 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCpuBackend(JNIEnv* env, jclass
return ToJString(env, "JIT");
}
-void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory(JNIEnv* env,
- jclass clazz,
- jstring j_path) {}
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_applySettings(JNIEnv* env, jobject jobj) {
+ EmulationSession::GetInstance().System().ApplySettings();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_logSettings(JNIEnv* env, jobject jobj) {
+ Settings::LogSettings();
+}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, jclass clazz,
jstring j_path) {
@@ -792,4 +824,60 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env,
return true;
}
+jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, jobject jobj,
+ jstring jpath,
+ jstring jprogramId) {
+ const auto path = GetJString(env, jpath);
+ const auto vFile =
+ Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path);
+ if (vFile == nullptr) {
+ return nullptr;
+ }
+
+ auto& system = EmulationSession::GetInstance().System();
+ auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+ const FileSys::PatchManager pm{program_id, system.GetFileSystemController(),
+ system.GetContentProvider()};
+ const auto loader = Loader::GetLoader(system, vFile);
+
+ FileSys::VirtualFile update_raw;
+ loader->ReadUpdateRaw(update_raw);
+
+ auto addons = pm.GetPatchVersionNames(update_raw);
+ auto jemptyString = ToJString(env, "");
+ auto jemptyStringPair = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
+ jemptyString, jemptyString);
+ jobjectArray jaddonsArray =
+ env->NewObjectArray(addons.size(), IDCache::GetPairClass(), jemptyStringPair);
+ int i = 0;
+ for (const auto& addon : addons) {
+ jobject jaddon = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
+ ToJString(env, addon.first), ToJString(env, addon.second));
+ env->SetObjectArrayElement(jaddonsArray, i, jaddon);
+ ++i;
+ }
+ return jaddonsArray;
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj,
+ jstring jprogramId) {
+ auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+
+ auto& system = EmulationSession::GetInstance().System();
+
+ Service::Account::ProfileManager manager;
+ // TODO: Pass in a selected user once we get the relevant UI working
+ const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
+ ASSERT(user_id);
+
+ const auto nandDir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
+ auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir),
+ FileSys::Mode::Read);
+
+ const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
+ system, vfsNandDir, FileSys::SaveDataSpaceId::NandUser, FileSys::SaveDataType::SaveData,
+ program_id, user_id->AsU128(), 0);
+ return ToJString(env, user_save_data_path);
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
index f1457bd1f..96c22d52b 100644
--- a/src/android/app/src/main/jni/native.h
+++ b/src/android/app/src/main/jni/native.h
@@ -54,6 +54,8 @@ public:
static void OnEmulationStarted();
+ static u64 GetProgramId(JNIEnv* env, jstring jprogramId);
+
private:
static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max);
static void OnEmulationStopped(Core::SystemResultStatus result);
diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp
index 9439d11e1..7f2485720 100644
--- a/src/android/app/src/main/jni/native_config.cpp
+++ b/src/android/app/src/main/jni/native_config.cpp
@@ -283,4 +283,30 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject
AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
}
+jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj,
+ jstring jprogramId) {
+ auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+ auto& disabledAddons = Settings::values.disabled_addons[program_id];
+ jobjectArray jdisabledAddonsArray =
+ env->NewObjectArray(disabledAddons.size(), IDCache::GetStringClass(), ToJString(env, ""));
+ for (size_t i = 0; i < disabledAddons.size(); ++i) {
+ env->SetObjectArrayElement(jdisabledAddonsArray, i, ToJString(env, disabledAddons[i]));
+ }
+ return jdisabledAddonsArray;
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj,
+ jstring jprogramId,
+ jobjectArray jdisabledAddons) {
+ auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+ Settings::values.disabled_addons[program_id].clear();
+ std::vector<std::string> disabled_addons;
+ const int size = env->GetArrayLength(jdisabledAddons);
+ for (int i = 0; i < size; ++i) {
+ auto jaddon = static_cast<jstring>(env->GetObjectArrayElement(jdisabledAddons, i));
+ disabled_addons.push_back(GetJString(env, jaddon));
+ }
+ Settings::values.disabled_addons[program_id] = disabled_addons;
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
new file mode 100644
index 000000000..0b9633855
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/list_all"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:fadeScrollbars="false"
+ android:scrollbars="vertical"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/icon_layout"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <LinearLayout
+ android:id="@+id/layout_all"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_horizontal"
+ android:orientation="horizontal">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_properties"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:listitem="@layout/card_simple_outlined" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+ <LinearLayout
+ android:id="@+id/icon_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <Button
+ android:id="@+id/button_back"
+ style="?attr/materialIconButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start"
+ android:layout_margin="8dp"
+ app:icon="@drawable/ic_back"
+ app:iconSize="24dp"
+ app:iconTint="?attr/colorOnSurface" />
+
+ <com.google.android.material.card.MaterialCardView
+ style="?attr/materialCardViewElevatedStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginTop="8dp"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="4dp">
+
+ <ImageView
+ android:id="@+id/image_game_screen"
+ android:layout_width="175dp"
+ android:layout_height="175dp"
+ tools:src="@drawable/default_icon" />
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/title"
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginTop="12dp"
+ android:ellipsize="none"
+ android:marqueeRepeatLimit="marquee_forever"
+ android:requiresFadingEdge="horizontal"
+ android:singleLine="true"
+ android:textAlignment="center"
+ tools:text="deko_basic" />
+
+ </LinearLayout>
+
+ <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+ android:id="@+id/button_start"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/start"
+ app:icon="@drawable/ic_play"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/card_installable.xml b/src/android/app/src/main/res/layout/card_installable.xml
index f5b0e3741..ce2402d7a 100644
--- a/src/android/app/src/main/res/layout/card_installable.xml
+++ b/src/android/app/src/main/res/layout/card_installable.xml
@@ -11,7 +11,8 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_margin="16dp"
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="24dp"
android:orientation="horizontal"
android:layout_gravity="center">
diff --git a/src/android/app/src/main/res/layout/card_applet_option.xml b/src/android/app/src/main/res/layout/card_simple_outlined.xml
index 19fbec9f1..b73930e7e 100644
--- a/src/android/app/src/main/res/layout/card_applet_option.xml
+++ b/src/android/app/src/main/res/layout/card_simple_outlined.xml
@@ -16,7 +16,8 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center"
- android:padding="24dp">
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="24dp">
<ImageView
android:id="@+id/icon"
@@ -50,6 +51,23 @@
android:textAlignment="viewStart"
tools:text="@string/applets_description" />
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.LabelMedium"
+ android:id="@+id/details"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAlignment="viewStart"
+ android:textSize="14sp"
+ android:textStyle="bold"
+ android:singleLine="true"
+ android:marqueeRepeatLimit="marquee_forever"
+ android:ellipsize="none"
+ android:requiresFadingEdge="horizontal"
+ android:layout_marginTop="6dp"
+ android:visibility="gone"
+ tools:visibility="visible"
+ tools:text="/tree/primary:Games" />
+
</LinearLayout>
</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_addons.xml b/src/android/app/src/main/res/layout/fragment_addons.xml
new file mode 100644
index 000000000..a25e82766
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_addons.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/coordinator_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_addons"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_addons"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:navigationIcon="@drawable/ic_back" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_addons"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:clipToPadding="false"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/appbar_addons" />
+
+ <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+ android:id="@+id/button_install"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom|end"
+ android:text="@string/install"
+ app:icon="@drawable/ic_add"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_game_info.xml b/src/android/app/src/main/res/layout/fragment_game_info.xml
new file mode 100644
index 000000000..80ede8a8c
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_game_info.xml
@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/coordinator_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_info"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_info"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:navigationIcon="@drawable/ic_back" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/scroll_info"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <LinearLayout
+ android:id="@+id/content_info"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingHorizontal="16dp">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/path"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/path_field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:editable="false"
+ android:importantForAutofill="no"
+ android:inputType="none"
+ android:minHeight="48dp"
+ android:textAlignment="viewStart"
+ tools:text="1.0.0" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/program_id"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/program_id_field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:editable="false"
+ android:importantForAutofill="no"
+ android:inputType="none"
+ android:minHeight="48dp"
+ android:textAlignment="viewStart"
+ tools:text="1.0.0" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/developer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/developer_field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:editable="false"
+ android:importantForAutofill="no"
+ android:inputType="none"
+ android:minHeight="48dp"
+ android:textAlignment="viewStart"
+ tools:text="1.0.0" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/version"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/version_field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:editable="false"
+ android:importantForAutofill="no"
+ android:inputType="none"
+ android:minHeight="48dp"
+ android:textAlignment="viewStart"
+ tools:text="1.0.0" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_copy"
+ style="@style/Widget.Material3.Button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/copy_details" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_game_properties.xml b/src/android/app/src/main/res/layout/fragment_game_properties.xml
new file mode 100644
index 000000000..72ecbde30
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_game_properties.xml
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/list_all"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scrollbars="vertical"
+ android:fadeScrollbars="false"
+ android:clipToPadding="false">
+
+ <LinearLayout
+ android:id="@+id/layout_all"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:gravity="center_horizontal">
+
+ <Button
+ android:id="@+id/button_back"
+ style="?attr/materialIconButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="8dp"
+ android:layout_gravity="start"
+ app:icon="@drawable/ic_back"
+ app:iconSize="24dp"
+ app:iconTint="?attr/colorOnSurface" />
+
+ <com.google.android.material.card.MaterialCardView
+ style="?attr/materialCardViewElevatedStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="4dp">
+
+ <ImageView
+ android:id="@+id/image_game_screen"
+ android:layout_width="175dp"
+ android:layout_height="175dp"
+ tools:src="@drawable/default_icon"/>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/title"
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="12dp"
+ android:layout_marginHorizontal="16dp"
+ android:ellipsize="none"
+ android:marqueeRepeatLimit="marquee_forever"
+ android:requiresFadingEdge="horizontal"
+ android:singleLine="true"
+ android:textAlignment="center"
+ tools:text="deko_basic" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_properties"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:listitem="@layout/card_simple_outlined" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+ <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+ android:id="@+id/button_start"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/start"
+ app:icon="@drawable/ic_play"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/list_item_addon.xml b/src/android/app/src/main/res/layout/list_item_addon.xml
new file mode 100644
index 000000000..74ca04ef1
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_addon.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/addon_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:focusable="true"
+ android:paddingHorizontal="20dp"
+ android:paddingVertical="16dp">
+
+ <LinearLayout
+ android:id="@+id/text_container"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="16dp"
+ android:orientation="vertical"
+ app:layout_constraintBottom_toBottomOf="@+id/addon_switch"
+ app:layout_constraintEnd_toStartOf="@+id/addon_switch"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="@+id/addon_switch">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/title"
+ style="@style/TextAppearance.Material3.HeadlineMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAlignment="viewStart"
+ android:textSize="17sp"
+ app:lineHeight="28dp"
+ tools:text="1440p Resolution" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/version"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/spacing_small"
+ android:textAlignment="viewStart"
+ tools:text="1.0.0" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/addon_switch"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:focusable="true"
+ android:gravity="center"
+ android:nextFocusLeft="@id/addon_container"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@id/text_container"
+ app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
index cf70b4bc4..1c69bf0db 100644
--- a/src/android/app/src/main/res/navigation/home_navigation.xml
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -124,5 +124,38 @@
android:id="@+id/gameFoldersFragment"
android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment"
android:label="GameFoldersFragment" />
+ <fragment
+ android:id="@+id/perGamePropertiesFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.GamePropertiesFragment"
+ android:label="PerGamePropertiesFragment" >
+ <argument
+ android:name="game"
+ app:argType="org.yuzu.yuzu_emu.model.Game" />
+ <action
+ android:id="@+id/action_perGamePropertiesFragment_to_gameInfoFragment"
+ app:destination="@id/gameInfoFragment" />
+ <action
+ android:id="@+id/action_perGamePropertiesFragment_to_addonsFragment"
+ app:destination="@id/addonsFragment" />
+ </fragment>
+ <action
+ android:id="@+id/action_global_perGamePropertiesFragment"
+ app:destination="@id/perGamePropertiesFragment" />
+ <fragment
+ android:id="@+id/gameInfoFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment"
+ android:label="GameInfoFragment" >
+ <argument
+ android:name="game"
+ app:argType="org.yuzu.yuzu_emu.model.Game" />
+ </fragment>
+ <fragment
+ android:id="@+id/addonsFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.AddonsFragment"
+ android:label="AddonsFragment" >
+ <argument
+ android:name="game"
+ app:argType="org.yuzu.yuzu_emu.model.Game" />
+ </fragment>
</navigation>
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index 380d14213..992b5ae44 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -13,7 +13,7 @@
<dimen name="menu_width">256dp</dimen>
<dimen name="card_width">165dp</dimen>
<dimen name="icon_inset">24dp</dimen>
- <dimen name="spacing_bottom_list_fab">76dp</dimen>
+ <dimen name="spacing_bottom_list_fab">96dp</dimen>
<dimen name="spacing_fab">24dp</dimen>
<dimen name="dialog_margin">20dp</dimen>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index a6ccef8a1..cd5571aa9 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -91,7 +91,10 @@
<string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
<string name="manage_save_data">Manage save data</string>
<string name="manage_save_data_description">Save data found. Please select an option below.</string>
+ <string name="import_save_warning">Import save data</string>
+ <string name="import_save_warning_description">This will overwrite all existing save data with the provided file. Are you sure that you want to continue?</string>
<string name="import_export_saves_description">Import or export save files</string>
+ <string name="save_files_importing">Importing save files…</string>
<string name="save_files_exporting">Exporting save files…</string>
<string name="save_file_imported_success">Imported successfully</string>
<string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
@@ -266,6 +269,11 @@
<string name="delete">Delete</string>
<string name="edit">Edit</string>
<string name="export_success">Exported successfully</string>
+ <string name="start">Start</string>
+ <string name="clear">Clear</string>
+ <string name="global">Global</string>
+ <string name="custom">Custom</string>
+ <string name="notice">Notice</string>
<!-- GPU driver installation -->
<string name="select_gpu_driver">Select GPU driver</string>
@@ -291,6 +299,43 @@
<string name="preferences_debug">Debug</string>
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
+ <!-- Game properties -->
+ <string name="info">Info</string>
+ <string name="info_description">Program ID, developer, version</string>
+ <string name="per_game_settings">Per-game settings</string>
+ <string name="per_game_settings_description">Edit settings specific to this game</string>
+ <string name="launch_options">Launch config</string>
+ <string name="path">Path</string>
+ <string name="program_id">Program ID</string>
+ <string name="developer">Developer</string>
+ <string name="version">Version</string>
+ <string name="copy_details">Copy details</string>
+ <string name="add_ons">Add-ons</string>
+ <string name="add_ons_description">Toggle mods, updates and DLC</string>
+ <string name="clear_shader_cache">Clear shader cache</string>
+ <string name="clear_shader_cache_description">Removes all shaders built while playing this game</string>
+ <string name="cleared_shaders_successfully">Cleared shaders successfully</string>
+ <string name="addons_game">Addons: %1$s</string>
+ <string name="save_data">Save data</string>
+ <string name="save_data_description">Manage save data specific to this game</string>
+ <string name="delete_save_data">Delete save data</string>
+ <string name="delete_save_data_description">Removes all save data specific to this game</string>
+ <string name="delete_save_data_warning_description">This irrecoverably removes all of this game\'s save data. Are you sure you want to continue?</string>
+ <string name="save_data_deleted_successfully">Save data deleted successfully</string>
+ <string name="select_content_type">Content type</string>
+ <string name="updates_and_dlc">Updates and DLC</string>
+ <string name="mods_and_cheats">Mods and cheats</string>
+ <string name="addon_notice">Important addon notice</string>
+ <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
+ <string name="addon_notice_description">In order to install mods and cheats, you must select a folder that contains a cheats/, romfs/, or exefs/ directory. We can\'t verify if these will be compatible with your game so be careful!</string>
+ <string name="invalid_directory">Invalid directory</string>
+ <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
+ <string name="invalid_directory_description">Please make sure that the directory you selected contains a cheats/, romfs/, or exefs/ folder and try again.</string>
+ <string name="addon_installed_successfully">Addon installed successfully</string>
+ <string name="verifying_content">Verifying content…</string>
+ <string name="content_install_notice">Content install notice</string>
+ <string name="content_install_notice_description">The content that you selected does not match this game.\nInstall anyway?</string>
+
<!-- ROM loading errors -->
<string name="loader_error_encrypted">Your ROM is encrypted</string>
<string name="loader_error_encrypted_roms_description"><![CDATA[Please follow the guides to redump your <a href="https://yuzu-emu.org/help/quickstart/#dumping-physical-titles-game-cards">game cartidges</a> or <a href="https://yuzu-emu.org/help/quickstart/#dumping-digital-titles-eshop">installed titles</a>.]]></string>